feat(identity): offline Ed25519 verifier — issuer-free identity for the auth seam - #234
Merged
Conversation
…he auth seam The first verifier for the seam merged in #232, and deliberately not an IdP one. WHY NOT OIDC FIRST. An OIDC verifier checks a token offline, but *obtaining* one needs the internet and the IdP up — and shortening token lifetimes to tighten revocation makes that dependency worse, so the two cannot be tuned together. This fleet is tailnet-scoped and dependency-free on purpose (tailnet-only binds, deny-by-default ACLs, --unshare-net on confined steps, "tailnet = reachability, not authorization"); an external IdP would be the first service the governance kernel needs UP to function, and a third party could lock the operator out of approving actions on their own fleet. This has no issuer, no JWKS, and no network in either direction. THE SHAPE. The bearer is a self-contained short-lived ASSERTION: a DSSE envelope — the same infra.cwp.sign surface that already signs grants and exod results, no new crypto — over {pub, iat, exp, nonce}, base64url'd to fit an Authorization header. The assertion carries its own public key, the signature is checked against THAT key, and the verifier returns sign.keyid(pub) = "ed25519:<16 hex>" as the subject. That is not circular, and the distinction is the whole design: anyone can mint a valid assertion with a key they generated and it WILL verify. What they cannot do is make it resolve to a principal — resolve_principal matches a subject only against one a registry entry DECLARES. So the signature proves possession of a key; the mounted principals.json decides whether that key is anybody. Revocation is deleting a line from a file already bind-mounted read-only into every node: instant, offline, no expiry window to wait out, no issuer to consult. Fail-closed everywhere, 21 tests, each asserting a refusal: - a VALID assertion from an undeclared key resolves to nobody (the property that matters most); - expired, future-dated (a lying clock cannot post-date its way to a long credential), and a hand-rolled assertion claiming a year-long TTL — the verifier does not trust the minter's clamp; - tampered payload (swapping the embedded pubkey breaks the signature — it is self-verifying); - a pubkey that is not 32 bytes; malformed/undecodable bearers; a bearer SECRET under this scheme; - REPLAY: a spent nonce is refused inside its own validity window, and when the bounded cache is full of LIVE nonces it refuses rather than evicting one and permitting a replay. Mutation-verified: disabling the replay check fails 2 tests; disabling signature verification fails the tamper test. The verifier never raises and never reveals WHY it refused — a caller that could tell "bad signature" from "expired" from "replayed" is an oracle, and govd answers 401 either way. Governed gate (cws-pm, 16 validators incl. grant forge/replay/expiry/wrong-run/capability): 16/16 (5d8cb29162d34fa4). Local: ed25519_auth, principals, govd, fleetd, acl all green. NEXT (not this PR): the OIDC overlay sits ABOVE this, never beside it — authenticate to an IdP, receive a short-lived Ed25519 certificate signed by an operator CA, verified offline against a pinned CA pubkey. The SSH-CA / Sigstore pattern. That moves the online dependency from every claim to periodic enrollment, and degrades correctly: without the overlay this half is a complete working system, enrolled by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rhCat
added a commit
that referenced
this pull request
Jul 26, 2026
…isters nothing (#235) * fix(identity): actually WIRE the built-in verifier — a config key registers nothing #234 shipped the Ed25519 verifier and #232 the seam, and between them the feature was unusable: nothing ever called register_verifier. Setting auth_verifier="ed25519" made every claim fail closed — correct behaviour, and a silently dead feature. Found by standing a real govd up from merged main with auth_verifier="ed25519" and watching a valid assertion resolve to nobody. No unit test could have caught it: both halves were individually correct. serve() now calls install_builtin_verifier(cfg) before the first claim. Only a name we SHIP is installed; an unknown name installs nothing and therefore resolves nobody, which is the intended fail-closed behaviour for a typo — it must never fall back to the bearer-secret path. Verifier CODE stays in the image by design: a verifier loaded from the mounted config would be ungoverned code executing inside the syscall boundary, which is the one thing this system exists to prevent. The mount configures WHICH verifier, never its body. Verified against a real govd over real HTTP (local, merged main, ed25519 scheme): alice's assertion -> 200 allow, and the signed chain records principal=alice undeclared key -> 401 bearer secret -> 401 no credential -> 401 That `principal=alice` is the point of the whole exercise: a named identity in the audit trail, which a shared per-node token can never provide. TESTS. Two unit tests pin the helper, and — because mutation showed deleting the serve() call left every unit test GREEN — an integration test starts a real govd on a free port and authenticates over HTTP, so the CALL SITE is covered, not just the function. Re-mutated after adding it: the suite now goes red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(govd): cover install_builtin_verifier in govd.py's RATCHET SLICE CI's enforcement-surface mutation ratchet caught this and it was right to: [FAIL] infra/govern/govd.py: score=0.95 floor=1.0 survivors=['==->!=@9986'] MUTATION REGRESSION below floor: [('infra/govern/govd.py', 0.95, 1.0)] govd.py was at a perfect 1.0 — every mutant killed — and the new `if name == "ed25519"` branch dropped it to 0.95. The branch was NOT untested: tests/test_ed25519_auth.py kills that mutant. But the ratchet drives each enforcement-surface module against a DESIGNATED slice (infra/govern/selfmonitor_policy.json: govd.py -> tests/test_govd.py), so a proof living anywhere else is invisible to it. Coverage for code in govd.py belongs in govd.py's slice; that policy is the point, not an obstacle to route around — the alternative (widening the slice) would dilute the ratchet for every module. Verified by re-running the exact CI survivor locally: mutating `==` to `!=` now fails tests/test_govd.py::test_install_builtin_verifier_registers_only_a_name_we_ship, and the suite is green restored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First verifier for the seam merged in #232, and deliberately not an IdP one.
Why not OIDC first
An OIDC verifier checks a token offline, but obtaining one needs the internet and the IdP up — and shortening token lifetimes to tighten revocation makes that dependency worse, so the two cannot be tuned together:
This fleet is tailnet-scoped and dependency-free on purpose. An external IdP would be the first service the governance kernel needs up to function, and a third party could lock the operator out of approving actions on their own fleet. This has no issuer, no JWKS, no network in either direction.
The shape
The bearer is a self-contained short-lived assertion: a DSSE envelope — the same
infra.cwp.signsurface that already signs grants and exod results, no new crypto — over{pub, iat, exp, nonce}, base64url'd to fit anAuthorizationheader. It carries its own public key, the signature is checked against that key, and the verifier returnssign.keyid(pub)=ed25519:<16 hex>as the subject.That is not circular, and the distinction is the whole design. Anyone can mint a valid assertion with a key they generated, and it will verify. What they cannot do is make it resolve to a principal —
resolve_principalmatches a subject only against one a registry entry declares:So the signature proves possession of a key; the mounted
principals.jsondecides whether that key is anybody. Revocation is deleting a line from a file already bind-mounted read-only into every node — instant, offline, no expiry window, no issuer.Fail-closed — 21 tests, each asserting a refusal
Mutation-verified: disabling the replay check fails 2 tests; disabling signature verification fails the tamper test.
The verifier never raises and never reveals why it refused — a caller that could tell "bad signature" from "expired" from "replayed" is an oracle, and govd answers 401 either way.
Verification
Governed gate (
cws-pm, 16 validators incl. grant forge / replay / expiry / wrong-run / capability): 16/16 (5d8cb29162d34fa4). Local suites green: ed25519_auth, principals, govd, fleetd, acl.Next, not this PR
The OIDC overlay sits above this, never beside it: authenticate to an IdP, receive a short-lived Ed25519 certificate signed by an operator CA, verified offline against a pinned CA pubkey — the SSH-CA / Sigstore pattern. That moves the online dependency from every claim to periodic enrollment, and degrades correctly: without the overlay, this half is a complete working system, enrolled by hand via
gen-acl.sh.🤖 Generated with Claude Code